Skip to content

Add mTLS authentication via Digipost mIdP to use JWT in client API calls - #372

Open
sondrew wants to merge 5 commits into
mainfrom
support_new_jwt_authentication
Open

Add mTLS authentication via Digipost mIdP to use JWT in client API calls#372
sondrew wants to merge 5 commits into
mainfrom
support_new_jwt_authentication

Conversation

@sondrew

@sondrew sondrew commented Aug 27, 2026

Copy link
Copy Markdown

💰 Funksjonell beskrivelse av endringen

Adds OAuth 2.0 client-credentials authentication as an alternative to authenticating with the organization certificate directly. The client presents its certificate over mTLS to Digipost's identity provider (mIdP) to obtain an access token, then authenticates API requests with that token as a bearer token. The functionality is backwards compatible: without jwtAuthentication(..) nothing changes.

This enables customers to use our clients without having to pay for an expensive enterprise certificate, and being able to use that certificate across multiple services (signing, digipost, etc). mIdP accepts certificates issued by Digipost's own certificate issuer, and the same certificate works across Digipost services. The equivalent change is done for all java/dotnet clients for digipost and signature. A certificate is still required — this changes which certificate is acceptable, not whether one is needed. It authenticates to mIdP and signs ASiC-E bundles (an XAdES signature over the document hashes, which needs the private key and embeds the certificate chain).

🏆 Interessante highlights

  • The certificate is presented only to mIdP, API requests are authenticated by the token alone, so mutual TLS is no longer used against the API. Validation of the API's server certificate is unchanged (Posten Bring organization number)
  • The predefined environments know their own token endpoint; custom environments can be given one with ServiceEnvironment.withTokenEndpoint(..)
  • No JWT is handled or verified here, the access token is treated as opaque and sent to the API
  • Token endpoint, scope and resource are derived, not caller-supplied: the token endpoint and resource from the ServiceEnvironment, the scope as signering-api:. All are exact-match contracts with mIdP
  • New public type BrokerId, supplied alongside the client id in JwtAuthConfig. The two are issued together and there is exactly one broker id per client id - specifically for signature-api, as there might be multiple brokerIds for one client in dpost-api
  • Broker and sender are independent. A given JWT client acquires tokens as one broker for its whole lifetime, so the scope never varies per request. Which sender a job is for is stated in the job, and a broker acting on behalf of several organizations still uses withSender(..) per job as before. defaultSender(..) is therefore not required in order to authenticate
  • Rejected tokens are recovered from. A 401 discards the cached token and retries once, but only for requests that are safe and repeatable — creating a signature job is never retried
  • No JSON parsing lib existed already, added jackson-core, not jackson-databind — streaming parser only, avoiding byte-buddy and byte-buddy-agent dependency
  • Question: Currently unsure how broker/sender functionality works in mIDP and signature. Can brokers send on behalf of multiple organizations and do they need a scope/token per sender? The scope currently derives from the default sender only,o could be an issue if needing multiple, looking for insight into how broker/sender works in mIDP and Signering
  • Still needs adding documentation

🤷‍♀️ Anbefalt fremgangsmåte

certificate --mTLS--> mIdP token endpoint --> access token
access token --Bearer--> Signering API (no client certificate)
certificate --> ASiC-E document signing (unchanged)

Public API

ClientConfiguration.builder(keyStoreConfig)
        .serviceEnvironment(ServiceEnvironment.PRODUCTION)
        .defaultSender(new Sender("123456789"))
        .jwtAuthentication(JwtAuthConfig.forClient("my-client-id", BrokerId.of("555444")))
        .build();
  1. build() resolves the token request from the ServiceEnvironment and the default sender.
  2. On the first API request, a client_credentials grant is POSTed to the environment's token
    endpoint over mTLS, authenticated with the organization certificate:
    scope=signering-api:, resource=.
  3. The token is cached and re-acquired lazily, 30 seconds before expiry.
  4. Both the default client and the document download client send Authorization: Bearer

Testing

  • Token acquisition, caching, refresh margin, invalidation, and concurrent callers sharing a single acquired token
  • Real TLS handshakes proving the client certificate is presented to the token endpoint and is not presented to the API, including document downloads (new localhost-tls-testserver.p12 fixture, since the existing test certificate is expired and not issued for localhost)
  • That the token endpoint's server certificate is properly validated
  • 401 recovery across safe/repeatable combinations
  • Malformed token responses: missing, empty, non-integer, non-positive and overflowing values
  • Proxy and User-Agent settings reaching the token endpoint

@sondrew
sondrew requested review from a team August 27, 2026 15:49
@Smurfz87
Smurfz87 requested a balanced review from Copilot August 27, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because all changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because all changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

@@ -0,0 +1,362 @@
package no.digipost.signature.client.core.internal.http;

import com.fasterxml.jackson.core.*;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skal ikke bruke wildcard imports

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, didn't know. Fikset IntelliJ config og queue'a import fix

@Smurfz87

Copy link
Copy Markdown

ClientConfiguration.Builder.accessTokenScope() (ClientConfiguration.java:472) leser scope-organisasjonsnummeret kun fra defaultSender, satt via Builder.defaultSender(...):

private String accessTokenScope() {
    Sender sender = defaultSender.getSender().orElseThrow(...);
    return ACCESS_TOKEN_SCOPE_PREFIX + sender.getOrganizationNumber();
}

Denne kalles én gang, i build(), når MutualTlsTokenProvider og AccessTokenRequest opprettes. Scopet fryses altså til defaultSender sitt org.nr. for hele klientens levetid.

Men biblioteket støtter uttrykkelig per-jobb sender-override — ment for meglere ("broker") som sender på vegne av flere organisasjoner. Se f.eks. PortalJob.Builder.withSender(Sender) (portal/PortalJob.java:149) og tilsvarende i DirectJob.Builder.

Konkret feilscenario

ClientConfiguration config = ClientConfiguration.builder(keyStoreConfig, environment)
        .defaultSender(new Sender("111111111"))   // orgA — brukes til å utlede token-scope
        .jwtAuthentication(jwtAuthConfig)
        .build();

// Senere: megler sender jobb for en ANNEN organisasjon
PortalJob job = PortalJob.builder(title, documents, signers)
        .withSender(new Sender("222222222"))      // orgB — jobben er faktisk for denne
        .build();

client.create(job);

Klienten har allerede hentet/cachet et access-token med scope signering-api:111111111 (orgA). Requesten sendes til API-et med withSender(orgB) i selve jobb-payloaden, men med et bearer-token som er scopet til orgA.

Konsekvens

  • API-et vil trolig avvise requesten pga. scope/organisasjons-mismatch — men feilen dukker opp som en opak autorisasjonsfeil fra serversiden, ikke som en tydelig client-side konfigurasjonsfeil.
  • Ingenting i koden validerer eller dokumenterer at defaultSender og en eventuell per-jobb withSender(...) må være samme organisasjon når JWT/mTLS-autentisering er i bruk.
  • Dette bryter trolig med hele hensikten med per-jobb sender — for JWT-klienter blir denne funksjonen i praksis ubrukelig/feil for annet enn defaultSenders egen org.

Mulige fikser

  1. Enkel/dokumentasjon: Kast ConfigurationException i build() dersom jwtAuthConfig != null — og dokumenter tydelig at per-jobb withSender(...) ikke støttes sammen med JWT-autentisering (ett scope per klient).
  2. Riktig fiks: Gjør token-provider/scope dynamisk — hent/cache token per organisasjonsnummer (map av org.nr. → token), og velg riktig token basert på jobbens faktiske sender (job.getSender().orElse(defaultSender)) i stedet for å fryse scopet ved build()-tidspunkt.

@sondrew

sondrew commented Aug 28, 2026

Copy link
Copy Markdown
Author

ClientConfiguration.Builder.accessTokenScope() (ClientConfiguration.java:472) leser scope-organisasjonsnummeret kun fra defaultSender, satt via Builder.defaultSender(...):

private String accessTokenScope() {
    Sender sender = defaultSender.getSender().orElseThrow(...);
    return ACCESS_TOKEN_SCOPE_PREFIX + sender.getOrganizationNumber();
}

Denne kalles én gang, i build(), når MutualTlsTokenProvider og AccessTokenRequest opprettes. Scopet fryses altså til defaultSender sitt org.nr. for hele klientens levetid.

Men biblioteket støtter uttrykkelig per-jobb sender-override — ment for meglere ("broker") som sender på vegne av flere organisasjoner. Se f.eks. PortalJob.Builder.withSender(Sender) (portal/PortalJob.java:149) og tilsvarende i DirectJob.Builder.

Konkret feilscenario

ClientConfiguration config = ClientConfiguration.builder(keyStoreConfig, environment)
        .defaultSender(new Sender("111111111"))   // orgA — brukes til å utlede token-scope
        .jwtAuthentication(jwtAuthConfig)
        .build();

// Senere: megler sender jobb for en ANNEN organisasjon
PortalJob job = PortalJob.builder(title, documents, signers)
        .withSender(new Sender("222222222"))      // orgB — jobben er faktisk for denne
        .build();

client.create(job);

Klienten har allerede hentet/cachet et access-token med scope signering-api:111111111 (orgA). Requesten sendes til API-et med withSender(orgB) i selve jobb-payloaden, men med et bearer-token som er scopet til orgA.

Konsekvens

* API-et vil trolig avvise requesten pga. scope/organisasjons-mismatch — men feilen dukker opp som en opak autorisasjonsfeil fra serversiden, ikke som en tydelig client-side konfigurasjonsfeil.

* Ingenting i koden validerer eller dokumenterer at `defaultSender` og en eventuell per-jobb `withSender(...)` må være samme organisasjon når JWT/mTLS-autentisering er i bruk.

* Dette bryter trolig med hele hensikten med per-jobb sender — for JWT-klienter blir denne funksjonen i praksis ubrukelig/feil for annet enn `defaultSenders` egen org.

Mulige fikser

1. Enkel/dokumentasjon: Kast `ConfigurationException` i `build()` dersom `jwtAuthConfig != null` — og dokumenter tydelig at per-jobb `withSender(...)` ikke støttes sammen med JWT-autentisering (ett scope per klient).

2. Riktig fiks: Gjør token-provider/scope dynamisk — hent/cache token per organisasjonsnummer (map av org.nr. → token), og velg riktig token basert på jobbens faktiske sender (`job.getSender().orElse(defaultSender)`) i stedet for å fryse scopet ved `build()`-tidspunkt.

@Smurfz87
Jeg var litt usikker på hvordan dette med sender/broker funket for både mIDP, Nyva og signering/digipost, så skrev det ned som et spørsmål/kommentar i PR description. Men virker da som det er støttet i mIDP/Nyva/apiene at en broker klient med nytt sertifikat kan sende på vegne av en annen virksomhet. Skal legge inn fiks

@sondrew

sondrew commented Aug 28, 2026

Copy link
Copy Markdown
Author

@Smurfz87 Etter å ha undersøkt en del mer rundt hvordan dette funker, så trengs det ikke dynamisk endring av scope alikevel. I signering-api så ligger ikke tilgangsstyringen/knytningen som tillater at en virksomhet kan sende på vegne av en annen i mIDP/Nyva, og i signering er virksomhet og konto 1-til-1.

Så for en gitt client man oppretter i Nyva, så kan det kun finnes én brokerId/senderId, men denne brokerId'en kan sende på vegne av andre vha withSenderId() slik som før (men dette håndteres/kontrolleres ikke i mIDP/Nyva, dette gjøres som autorisasjon i api'ene selv). Har oppdatert PR beskrivelsen, håper det gir mening nå!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants